03 - eBPF 旁路采集
前置:02 篇的四条路线,本篇展开其中的 D · 进程外旁路。
本篇回答:不改一行代码、不装任何库,能拿到多少 Agent 可观测数据。以及为了这份"零改动",你付出了什么。
本篇会用到的词:
| 词 | 意思 |
|---|---|
| eBPF | 内核里的一个受限虚拟机。你可以往内核挂一段经过验证器检查的小程序,它在特定事件发生时执行,能读数据但不能让内核崩溃 |
| uprobe / uretprobe | 用户态函数的入口断点 / 返回断点。挂在某个共享库的某个符号上,进程每次调用它就触发一次 eBPF 程序 |
| BTF | BPF Type Format,内核把自己的数据结构布局编译进去的一份元信息。有它才能写"一次编译到处运行"的 eBPF 程序 |
| libssl | OpenSSL 的共享库文件。绝大多数语言的 HTTPS 客户端最终调它做加解密 |
| SSE | Server-Sent Events,流式响应的传输格式。data: {...} 一行一个分块 |
| 载荷(payload) | HTTP 请求体和响应体里的实际内容,相对于头部而言 |
一、它凭什么能看到加密流量
HTTPS 意味着网络上抓到的是密文。旁路采集要拿到明文,只有一个位置:加密函数被调用的那一刻,参数还是明文。
代码侧就是一张挂载表:
// pkg/internal/ebpf/generictracer/generictracer.go
func (p *Tracer) UProbes() map[string]map[string][]*ebpfcommon.ProbeDesc {
m := map[string]map[string][]*ebpfcommon.ProbeDesc{
"libssl.so": {
// Start 是入口断点,End 是返回断点。写方向在入口取参数,
// 读方向要在返回时取 —— 因为调用发生时缓冲区还是空的
"SSL_read": {{Start: ...ObiUprobeSslRead, End: ...ObiUretprobeSslRead}},
"SSL_write": {{Start: ...ObiUprobeSslWrite, End: ...ObiUretprobeSslWrite}},
// ...还有 _ex / _ex2 变体和 SSL_shutdown
},
// .NET 不直接链 libssl,它有一层自己的 OpenSSL 封装,符号名不同
"libSystem.Security.Cryptography.Native.OpenSsl.so": {
"CryptoNative_SslRead": {{...}},
"CryptoNative_SslWrite": {{...}},
},
}
}
二、它认识哪些 GenAI 流量
挂上探针只是拿到了 HTTP 载荷,把载荷解成 gen_ai.* 属性是另一回事,而且必须逐家 vendor 写规则。OBI 的开关列在 pkg/config/payload_extraction.go:
# 全部默认关闭,要显式打开。也可以用等价的环境变量,
# 比如 OTEL_EBPF_HTTP_OPENAI_ENABLED=true
payload_extraction:
http:
genai:
openai: {enabled: true} # 含全部 OpenAI 兼容响应的兜底解析
anthropic: {enabled: true}
gemini: {enabled: true} # Google AI Studio
qwen: {enabled: true} # 阿里 DashScope
bedrock: {enabled: true}
ollama: {enabled: true} # 原生 /api/chat 与 /api/generate
mcp: {enabled: true} # MCP 的 JSON-RPC 调用
embedding: {enabled: true} # Voyage / Cohere / Jina
rerank: {enabled: true} # 同上三家 + DashScope
retrieval: {enabled: true} # Pinecone / Qdrant / Milvus / Zilliz / Chroma / Weaviate
openai_compatible: # 自建网关要显式列白名单,见下
enabled: true
gateways:
- {host: "llm-gateway.internal", port: 8080, provider: "litellm"}
最后那个 openai_compatible 值得单说。官方支持矩阵里点名的是 LiteLLM、vLLM、LocalAI、OpenRouter、Ollama 的 /v1/ 端点 —— 也就是说,你自建的 LLM 网关只要说 OpenAI 协议,加一条 host 白名单就能被识 别。 这对国内团队意义不小:绝大多数自建推理服务都是 OpenAI 兼容接口。
配套的 provider 字段是必须填的,因为流量里没有任何东西能告诉 OBI 后面接的是谁。
2.1 与 02 篇那两个埋点库的覆盖对比
| 覆盖对象 | OBI(eBPF) | OpenInference / OpenLLMetry |
|---|---|---|
| 模型 API 调用 | ✅ 11 类 vendor | ✅ |
| 向量库检索 | ✅ 6 家 | ✅ OpenLLMetry 7 家 |
| MCP 调用 | ✅ 走 HTTP 的 | ✅ 含 stdio 传输 |
| 编排框架内部节点 | ❌ | ✅ LangGraph / CrewAI / ADK 等 |
| 本地推理(同进程加载模型) | ❌ 没有网络调用 | ✅ transformers 埋点器 |
| 未装任何库的服务 | ✅ | ❌ |
"MCP 走 HTTP 的"那一行是个典型的边界:MCP 的 stdio 传输是父子进程间的管道,不经过 libssl.so,OBI 看不到。而本地 MCP server 恰恰大多用 stdio。
三、它怎么判断"这是一次模型调用"
这是旁路方案最脆弱的一环,值得原文看一遍。OBI 判 OpenAI 的逻辑是三级降级:
// pkg/ebpf/common/http/openai.go
func OpenAISpan(baseSpan *request.Span, req *http.Request, resp *http.Response) (request.Span, bool) {
// 第一级:认响应头。OpenAI 会带这几个自有头,命中即确定
isOpenAI := false
for _, header := range []string{"Openai-Version", "Openai-Organization",
"Openai-Project", "Openai-Processing-Ms"} {
if val := resp.Header.Get(header); val != "" { isOpenAI = true; break }
}
maybeOpenAI := false
if !isOpenAI {
// 第二级:HTTP/2 场景下头部被 HPACK 压缩,探针拿不到可用的头,
// 只能退回看路径里有没有 /v1/。不满足就直接放弃这条流量
if !isHTTP2Request(req) || !strings.Contains(baseSpan.Path, "/v1/") {
return *baseSpan, false
}
maybeOpenAI = true
}
// ...读请求体和响应体...
if maybeOpenAI {
// 第三级:靠模型名的字符串前缀猜
if !looksLikeOpenAIBody(reqB, respB, baseSpan.Path) { return *baseSpan, false }
}
}
func looksLikeOpenAIBody(reqB, respB []byte, path string) bool {
model := strings.ToLower(genaiModel(reqB, respB))
// DashScope 的嵌入模型叫 text-embedding-v<N>,让给 Qwen 的识别器
if isDashScopeEmbeddingModel(model) { return false }
// 「gpt」覆盖 chat/completions 与 responses,「text-embedding」覆盖 embeddings
return strings.HasPrefix(model, "gpt") ||
(strings.HasPrefix(model, "text-embedding") && strings.Contains(path, "/v1/embeddings"))
}
第三级是字符串前缀匹配。 直接后果:
| 场景 | 结果 |
|---|---|
HTTP/2 + 模型名叫 qwen-max 打到 OpenAI 兼容端点 | 三级都不命中,这次调用不产生 GenAI span |
自建服务把模型名起成 gpt-internal-v3 | 被认成 OpenAI,gen_ai.provider.name 标错 |
| 走 HTTP/1.1 且是真 OpenAI | 第一级就命中,准确 |
所以自建网关一定要走 openai_compatible 白名单显式声明,别指望启发式认得出你的模型名。
3.1 流式响应是分开处理的
func parseOpenAICompatibleResponse(respB []byte) (*request.VendorOpenAI, []request.ToolCall) {
if looksLikeJSON(respB) { // 非流式:整个响应体是一个 JSON
resp := parseVendorOpenAI(respB)
return &resp, extractToolCalls(resp.Choices)
}
return parseOpenAIStream(bytes.NewReader(respB)) // 流式:按 SSE 逐行解
}
流式能解,但要注意:OBI 是在整段响应收完之后才解析的。 这意味着它给得出总耗时和 token 用量,给不出 01 篇 3.1 节那个 time_to_first_chunk(TTFT)—— 首字延迟需要在第一个分 块到达的瞬间打点,那是进程内埋点的活。
要 TTFT 就得走做法 B/C,或者在网关侧记(06 篇)。这条在选型时最容易被忽略,因为 TTFT 恰恰是用户感知最强的那个指标。
四、部署门槛
不是所有集群都跑得起来。硬要求来自官方支持矩阵:
| 项 | 要求 |
|---|---|
| 内核 | Linux 5.8+;RHEL 系带 eBPF backport 的可放宽到 4.18+(RHEL 8 / CentOS 8 / Rocky 8 / AlmaLinux 8) |
| BTF | 内核必须暴露 BTF 信息 |
| 架构 | 只有 amd64 和 arm64 有发布产物 |
| 权限 | root,或所启用功能对应的那组 Linux capabilities |
权限那一条是实际落地时的主要摩擦点,和 02 篇 4.3 节里 Operator 注入 Go 应用要 privileged: true 是同一类问题:旁路方案的能力来自特权,而多数生产集群的 PodSecurity 策略默认不给特权。 这需要平台团队和安全团队先谈拢,不是技术选型能单方面决定的。
另外 OBI 自己也说了它还在 Development 阶段,v0 的小版本之间允许破坏性变更,README 里明确写着:不要用 latest 标签、升级前读发布说明、别假设仪表盘和告警能跨版本延续。